A prior post covered how some teams at BTI360 use CloudFormation to manage Terraform’s AWS backend infrastructure, including the state bucket and lock table. Our previous post introduced three permission levels for accessing Terraform state:
- Backend: A dedicated role Terraform will use when accessing and modifying state during operations performed by IAM users or CI/CD.
- Developer: Permissions needed for manual modifications/intervention by developers. Restricted from permanently deleting state.
- Administrative: Has full access to state buckets and objects. Access should be highly restricted.
That same post implemented the backend role. Today, Chris, BTI360 engineer and author of thirstydeveloper.io, will add RBAC to protect the Terraform state and enforce the Developer and Administrative permission levels.
State RBAC
When we discussed using CloudFormation to control the state bucket, we pointed out several hardening opportunities above and beyond what Terragrunt does for you. One of those was using an S3 bucket policy to restrict access to Terraform state. We will use just such a bucket policy to implement our RBAC restrictions.
An S3 bucket policy is an ideal choice for two reasons. First, it directly applies permissions to the resource we want to protect (the state bucket). Second, developers can likely create it themselves, unlike adding permissions to IAM users, something typically controlled by an enterprise team.
We’ll start with a bucket policy that restricts access to just our backend role. Once that’s in place, we’ll add developer and administrative access.
An important note before we begin: if you manage to lock yourself out of an S3 bucket with a bucket policy, you will need access to the AWS account’s root user to recover. AWS always allows the root user access to remove or modify bucket policies of buckets owned by that root user’s account, even if the bucket’s policy does not explicitly grant it.
State Bucket Policy
We’ll add our state bucket policy as a resource to the terraform-bootstrap.cf.yml
CloudFormation template we previously created. If you care to jump to the end, here’s a link to the full CloudFormation template. Let’s break the policy down statement by statement.
We’ll start with a statement that requires TLS encryption for any requests accessing Terraform state. This policy Terragrunt creates for you; we preserve it here.
TerraformStateBucketPolicy: Type: 'AWS::S3::BucketPolicy' DeletionPolicy: Retain UpdateReplacePolicy: Retain Properties: Bucket: !Ref TerraformStateBucket PolicyDocument: Version: '2012-10-17' Statement: - Sid: 'AllowTLSRequestsOnly' Principal: '*' Condition: Bool: 'aws:SecureTransport': false Effect: Deny Action: '*' Resource: - !GetAtt "TerraformStateBucket.Arn" - !Sub - "${Bucket}/*" - Bucket: !GetAtt "TerraformStateBucket.Arn"
A second statement denies access to all IAM roles other than our backend role:
- Sid: DenyNonBackendRoles Principal: "*" Condition: StringEquals: aws:PrincipalType: AssumedRole StringNotLike: aws:userId: - !Sub - "${TerraformBackendRoleId}:*" - TerraformBackendRoleId: !GetAtt "TerraformBackendRole.RoleId" Effect: Deny Action: '*' Resource: - !GetAtt "TerraformStateBucket.Arn" - !Sub - "${Bucket}/*" - Bucket: !GetAtt "TerraformStateBucket.Arn"
The syntax for restricting access to a specific IAM role can be unintuitive. Specifying the role ARN in the Principal
property does not work; this AWS post explains why and demonstrates the combination of StringNotLike
and aws:userId we use here.
A third statement grants the backend role access:
- Sid: ResrictBackendRoleToReadWrite Principal: "*" Condition: StringEquals: aws:PrincipalType: AssumedRole StringLike: aws:userId: - !Sub - "${TerraformBackendRoleId}:*" - TerraformBackendRoleId: !GetAtt "TerraformBackendRole.RoleId" Effect: Deny NotAction: - 's3:ListBucket' - 's3:GetBucketVersioning' - 's3:GetObject' - 's3:PutObject' Resource: - !GetAtt "TerraformStateBucket.Arn" - !Sub - "${Bucket}/*" - Bucket: !GetAtt "TerraformStateBucket.Arn"
And a fourth statement denies access to any principal types other than AWS accounts, IAM users, and IAM roles, as those are the only principal types we are considering today.
- Sid: DenyAllOtherPrincipals Principal: "*" Condition: StringNotEquals: aws:PrincipalType: - AssumedRole - Account - User Effect: Deny Action: '*' Resource: - !GetAtt "TerraformStateBucket.Arn" - !Sub - "${Bucket}/*" - Bucket: !GetAtt "TerraformStateBucket.Arn"
Our policy so far prevents all IAM roles other than the backend role from accessing Terraform state. We’ll now turn our attention to IAM users, specifically our Developers and Administrators.
Developer and Administrator Users
We previously granted IAM users access to our backend role using an IAM principal tag condition, reprinted below:
TerraformBackendRole: Type: 'AWS::IAM::Role' Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: AWS: !Ref AWS::AccountId Action: - 'sts:AssumeRole' Condition: StringEquals: aws:PrincipalType: User StringLike: 'aws:PrincipalTag/Terraformer': '*' RoleName: TerraformBackend Path: /terraform/ ManagedPolicyArns: - !Ref TerraformStateReadWritePolicy
Anyone running Terraform or Terragrunt commands, such as our Developers and Administrators, will need the Terraformer
principal tag on their IAM user to assume the backend role to modify state. The backend role grants sufficient access for Developers and Administrators to run plans and applies, but other advanced operations require delete access to the state. The most common is state migration (e.g., renaming a state file). We will want to grant Developers and Administrators direct access to the state bucket to support such operations.
Since both types of IAM users will have the Terraformer
principal tag, we can use that tag’s value to distinguish Developer and Administrative access. Let’s see how.
User Bucket Policy Statements
Our next bucket policy statement uses the aws:PrincipalType and aws:PrincipalTag condition keys to deny access to any IAM users lacking the Terraformer
principal tag:
- Sid: DenyNonTerraformerUsers Principal: "*" Condition: StringEquals: aws:PrincipalType: User StringNotLike: 'aws:PrincipalTag/Terraformer': '*' Effect: Deny Action: '*' Resource: - !GetAtt "TerraformStateBucket.Arn" - !Sub - "${Bucket}/*" - Bucket: !GetAtt "TerraformStateBucket.Arn"
Next, we begin differentiating between developer and administrative access to the state by inspecting the value of the Terraformer
tag:
- Sid: RestrictTerraformNonAdmins Principal: "*" Condition: StringEquals: aws:PrincipalType: User StringLike: 'aws:PrincipalTag/Terraformer': '*' StringNotEquals: 'aws:PrincipalTag/Terraformer': 'Admin' Effect: Deny NotAction: - 's3:List*' - 's3:Get*' - 's3:Describe*' - 's3:PutObject' - 's3:DeleteObject' Resource: - !GetAtt "TerraformStateBucket.Arn" - !Sub - "${Bucket}/*" - Bucket: !GetAtt "TerraformStateBucket.Arn"
If the IAM user has the Terraformer
tag, but its value is not Admin
, we grant developer-level access to that user. We use IAM’s NotAction
to whitelist the permitted actions. The bucket policy does not contain any permissions for users who have the Terraformer
tag set to Admin
. The lack of permissions means such users will have whatever access the IAM policy attached to their IAM user grants, presumably full access to S3.
Notably, developer-level access permits s3:DeleteObject
but not s3:DeleteObjectVersion
. Since our state bucket is versioned (see our previous post), granting s3:DeleteObject
is not inherently dangerous because all it does is add a delete marker to the object; you can always restore the version before the delete marker. Granting developers the ability to add delete markers aids state migration, so we do so here.1
Finally, none of the permissions in the policy grant either adding or modifying the bucket policy, which means that aside from the root user, only Admin
Terraformers can do so, assuming those admins have the requisite permissions on their IAM user.
And there we have it: a bucket policy restricting Terraform state access to only our backend role and IAM users with the Terraformer
tag, distinguishing between administrative and developer-level access for the latter. All that’s left is to deploy the bucket policy.
Deploying the Bucket Policy
First, so you don’t lock yourself out, change the Terraformer
tag on your IAM user to have a value of Admin
and ensure you have full access to S3.
Second, if Terragrunt created the state bucket for you, it may already have a bucket policy attached to it. Delete it using the following CLI command, replacing the bucket name as appropriate:
aws s3api delete-bucket-policy --bucket bti360-terraform-state
Third, deploy your template as a CloudFormation stack either with the CloudFormation management console or using the AWS CLI. Here’s a sample command for the latter:
aws cloudformation deploy \ --template-file terraform-bootstrap.cf.yml --stack-name terraform-bootstrap \ --capabilities CAPABILITY_NAMED_IAM \ --parameter-overrides \ StateBucketName=bti360-terraform-state \ StateLogBucketName=bti360-terraform-state-logs \ LockTableName=bti360-terraform-state-locks
You’re all set.
Conclusion
In the last two entries, we implemented role-based access control for Terraform state with three basic permission-levels. We hope your team finds this a useful starting point for protecting your Terraform state. If you care to see a fully worked example incorporating the RBAC concepts introduced today, check out Chris’ terraform-skeleton series on thirstydeveloper.io.
Footnotes
- Since users cannot delete object versions, they cannot restore a state file by deleting its delete marker since it is itself an object version.As discussed in the AWS docs here and here, users can still restore state files by copying a previous version to become the latest. See thirstydeveloper.io for an example of how.
Interested in Solving Challenging Problems? Work Here!
Are you a software engineer, interested in joining a software company that invests in its teammates and promotes a strong engineering culture? Then you’re in the right place! Check out our current Career Opportunities. We’re always looking for like-minded engineers to join the BTI360 family.